39 — 粒子光标动画着色器

Three.js Journey · Bruno Simon · 难度:简单

39 — 粒子光标动画着色器课程海报

简介

在本课中,我们将学习如何在一张由粒子构成的图片上创建一种很酷的光标动画:

lesson demo video preview

借此机会,我们还可以学习如何将一个 2D canvas 当作纹理使用,把它发送到自定义 shader 中,用来让粒子产生位移。

设置

起始项目已经包含了一些准备好被增强和动画化的粒子:

lesson demo video preview

粒子的大小已经在 vertex.glsl 中处理好了,考虑了透视效果并相对于渲染高度,就像之前课程中看到的那样:

lesson demo video preview

默认的粒子相当大,这样我们不必每次刷新都去缩放,就能欣赏到我们所做的效果。

一旦我们对粒子的外观满意了,我们就会把它们调小,并添加更多的粒子。

粒子

我们将使用 static/ 文件夹里的一张图片,让这张图片显现在粒子上。

我们希望粒子是圆盘而不是方块。我们希望它们的大小取决于图片的亮度,并且我们还想修改粒子的颜色,使其与图片相匹配。

圆盘

让我们从粒子的形状开始,让它们看起来像圆盘。这次我们不想要花哨的图案,只要完美的圆盘。

首先,我们需要粒子内部的 UV 坐标,而由于它们是点(point),我们可以使用 gl_PointCoord。

在 fragment.glsl 中,把 gl_PointCoord 保存为一个 vec2 uv 变量:

void main()
{
    vec2 uv = gl_PointCoord;
    
    // ...
}

出于好奇,把它作为前两个参数发送到 gl_FragColor:

void main()
{
    vec2 uv = gl_PointCoord;

    gl_FragColor = vec4(uv, 1.0, 1.0);
    // ...
}

要绘制一个圆盘,我们需要 fragment 到粒子中心的距离,我们可以像之前课程中看到的那样,使用 distance 函数来实现。

创建一个 float distanceToCenter 变量,保存 uv 与 vec2(0.5) 之间的距离:

void main()
{
    vec2 uv = gl_PointCoord;
    float distanceToCenter = distance(uv, vec2(0.5));

    // ...
}

出于好奇,把它作为前 3 个值发送到 gl_FragColor:

void main()
{
    vec2 uv = gl_PointCoord;
    float distanceToCenter = distance(uv, vec2(0.5));

    gl_FragColor = vec4(distanceToCenter, distanceToCenter, distanceToCenter, 1.0);
    // ...
}

它奏效了,但其实还有另一种获取到中心距离的方法,那就是先减去 0.5,再取 uv 的 length:

void main()
{
    vec2 uv = gl_PointCoord;
    float distanceToCenter = length(uv - vec2(0.5));

    gl_FragColor = vec4(distanceToCenter, distanceToCenter, distanceToCenter, 1.0);
    // ...
}

这两种技巧没有太大区别,我觉得这种方法比使用 distance 稍微绕一点,这也是我在教初学者时通常会避开它的原因。但由于它用得相当频繁,我觉得讲一讲也不错。

我们已经有了到中心的距离,可以用它来处理 alpha。但正如之前课程中所见,alpha 容易造成视觉 bug,而有一个更合适的解决方案叫做 “discard”(丢弃)。

discard 是一条指令,只要写上 discard; 就能调用。它会完全阻止该 fragment 被绘制,甚至不需要依赖透明度。

就好像那里什么都没有一样,尽管几何体表明并非如此。

在我们的情况中,我们希望在 distanceToCenter 大于 0.5 时丢弃该 fragment。我们可以使用 if 语句:

void main()
{
    vec2 uv = gl_PointCoord;
    float distanceToCenter = length(uv - vec2(0.5));

    if(distanceToCenter > 0.5)
        discard;

    // ...
}

我们的粒子看起来像圆盘,而且不会有任何透明度问题。

请注意,discard; 可能带来难以预料的性能影响,但通常是微不足道的。

把 1.0 放回 gl_FragColor,这样我们就得到白色的粒子:

void main()
{
    // ...

    gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
    // ...
}

纹理

有些图片位于 static/ 中。我们要加载其中一张并把它发送到 shader,以便在粒子上使用它。

我们已经有了一个 TextureLoader 实例。我们将使用经典的 Uniform 创建一个 uPictureTexture uniform,并直接把纹理加载进去:

const particlesMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        // ...
        uPictureTexture: new THREE.Uniform(textureLoader.load('./picture-1.png'))
    }
})

我选择了 picture-1.png,但你可以随意使用另一张,甚至是你自己制作的。如果你使用自定义图片,请确保它是灰度且正方的。

在 vertex.glsl 中,以 sampler2D 的形式获取 uPictureTexture:

uniform sampler2D uPictureTexture;

在 Point size 部分之前,使用 texture() 函数在 uv 坐标处从 uPictureTexture 中取出颜色,并 swizzle 出 r 通道,保存到 float pictureIntensity 变量中:

void main()
{
    // ...

    // Picture
    float pictureIntensity = texture(uPictureTexture, uv).r;

    // Point size
    // ...
}

尽管我们把这个几何体用于 point,我们依然可以访问几何体的 uv 属性。此外,该 uv 对应平面(plane)的默认 UV(左下角为 0, 0,右上角为 1, 1)。

使用 pictureIntensity 乘以 gl_PointSize:

void main()
{
    // ...

    // Point size
    gl_PointSize = 0.3 * pictureIntensity * uResolution.y;
    gl_PointSize *= (1.0 / - viewPosition.z);
}

我们已经能辨认出那张图片了,尽管要真正欣赏它还需要更多的粒子,这一点我们稍后会做。

改变粒子的大小是个好的开始,但我们还要改变颜色,让小的粒子更暗。

创建一个 vec3 vColor varying,并把 pictureIntensity 赋给它:

varying vec3 vColor;

void main()
{
    // ...

    // Varyings
    vColor = vec3(pictureIntensity);
}

在 fragment.glsl 中,获取 vColor varying 并把它设置到 gl_FragColor 的前三个值上:

varying vec3 vColor;

void main()
{
    // ...

    gl_FragColor = vec4(vColor, 1.0);
    // ...
}

相当不错,但我们要让暗的粒子更暗,同时保持亮的粒子亮度不变。为此,我们将通过施加一个幂(power)来压暗 vColor:

在 vertex.glsl 中,对 pictureIntensity 使用 pow:

void main()
{
    // ...

    // Varyings
    vColor = vec3(pow(pictureIntensity, 2.0));
}

更多粒子

我们的粒子准备好了。让我们通过把 PlaneGeometry 的细分提高到 128 来添加更多粒子:

const particlesGeometry = new THREE.PlaneGeometry(10, 10, 128, 128)

在 vertex.glsl 中把它们的基础大小减小到 0.15:

void main()
{
    // ...

    // Point size
    gl_PointSize = 0.15 * pictureIntensity * uResolution.y;
    // ...
}

把这些调节项加到调试面板上会更好,但我们还是保持简单吧。

位移原理

我们有了粒子。现在我们想在光标悬停在图片上时对它们进行动画。

更好的是,我们希望有一种拖尾效果:即使光标已经离开,粒子也会在短暂的一瞬间保持抬起的状态。

由于这种拖尾效果,我们不能仅仅把光标坐标发送到 shader 并用来移动粒子。我们需要效果具有一定的持续性(persistence),为此我们将使用一个 canvas。

一个 canvas?像 WebGL 的 canvas 那样吗?

不。我们要使用一个 2D canvas。如果你不知道 2D canvas 是什么,它其实就是 <canvas> 在 WebGL 出现之前的用途,而且至今仍然可用。

我们将创建一个填满黑色的 2D canvas,在每一帧于光标应该在的位置绘制一个白色的光晕,每一帧都让整个 canvas 稍微淡出一点,然后把这个 canvas 当作粒子的位移纹理来使用。

如果这听起来不太明白,看看课程开头的视频,你会看到左上角的那个 2D canvas。

2D 画布

什么是 2D 画布

在创建那个 2D canvas 之前,让我们先解释一下它是什么以及它如何工作,以防你还没用过它。

这并不是一堂专门讲 2D canvas 的课,要精通它得用整整一章的篇幅,但这是一个学习一些基础知识、并理解何时以及如何使用它的机会。

<canvas> 在 WebGL 诞生之前很久就已经存在了。借助它,我们能够做很多事情:

听起来很棒,但它的局限在于绘制是由 CPU 完成的,不像 WebGL 那样主要依赖 GPU。

这会造成性能问题,并限制了我们能用它做的事情,尤其是在每一帧都进行动画时。

尽管如此,我们仍然可以创作出能在大多数设备上运行的绘图和简单动画。不要低估 CPU 的能力。

我们将创建一个 2D canvas,并花一段时间专注于它。

顺便说一句,有一份著名的 canvas 速查表,你应该把它放在手边:https://simon.html5.org/dump/html5-canvas-cheat-sheet.html

创建 2D 画布

由于我们将会有很多变量,我们会把这部分组织到一个 displacement 对象中,以便与其余部分分开。

在 Particles 部分之前,创建一个 displacement 对象:

/**
 * Displacement
 */
const displacement = {}

首先,我们需要一个真正的 <canvas> 元素,就像 WebGL 那样。我们可以在 HTML 中创建它,也可以直接从 JS 中创建。

我们用 JS 来做。使用 document.createElement() 方法,并把它保存为一个 canvas 属性:

/**
 * Displacement
 */
const displacement = {}

// 2D canvas
displacement.canvas = document.createElement('canvas')

使用 width 和 height 属性把尺寸设为 128:

// 2D canvas
displacement.canvas = document.createElement('canvas')
displacement.canvas.width = 128
displacement.canvas.height = 128

然后使用 document.body.append() 把它添加到 DOM 中:

document.body.append(displacement.canvas)

请注意,把 canvas 元素添加到 DOM 中不是必须的,但我们想看看要在其中绘制什么。

<canvas> 已经在 DOM 中了,但它是透明的、太小,而且在 WebGL canvas 后面:

lesson demo video preview

让我们通过直接在元素上设置一些 style 来解决这个问题:

// 2D canvas
// ...
displacement.canvas.style.position = 'fixed'
displacement.canvas.style.width = '512px'
displacement.canvas.style.height = '512px'
displacement.canvas.style.top = 0
displacement.canvas.style.left = 0
displacement.canvas.style.zIndex = 10
document.body.append(displacement.canvas)

我们修改了 style 的 width 和 height,这并不会影响构成 canvas 的实际像素数量。这只是为了让我们能更清楚地看到它:

lesson demo video preview

2D canvas 在这里了,但它目前是透明的。

上下文(Context)

要在这个 canvas 上绘制,我们需要所谓的“上下文(context)”,你可能在 WebGL 中听说过它。其实 Three.js 就是从 canvas 中获取一个 “WebGL context”。

但在我们的情况中,我们想要的不是 webgl 上下文,而是 2d 上下文。

在 displacement.canvas 上调用 getContext('2d'),并把它保存为 displacement.context:

// Context
displacement.context = displacement.canvas.getContext('2d')

填充画布

我们已经具备了在 canvas 上绘制所需的一切,我们将首先用纯黑色把它填满。

记住,要在 canvas 上绘制,你需要使用 context,而那个 context 包含了一堆方法,其中之一是 fillRect(),顾名思义,它会填充一个矩形。

在获取 context 之后,用以下参数调用 fillRect():

// Context
displacement.context = displacement.canvas.getContext('2d')
displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)

前两个参数是矩形的 x 和 y 坐标,后两个参数是 width 和 height,这就是为什么 canvas 被完全填满了。

而由于默认的填充样式是黑色,我们得到了一个黑色的 canvas。

虽然黑色对我们来说没问题,但为了学习,在调用 fillRect() 之前把 fillStyle 属性设为 'red':

displacement.context = displacement.canvas.getContext('2d')
displacement.context.fillStyle = 'red'
displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)

这就是 2D canvas 的工作方式:你定义一个样式,然后绘制形状。

这只是一个示例,因为我们希望 canvas 是黑色的。移除 fillStyle:

// Context
displacement.context = displacement.canvas.getContext('2d')
displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)

绘制光晕

static/ 文件夹里有一个 glow.png 文件。这个光晕是一个灰度的径向渐变,我们将用它来在 canvas 上光标所在的位置进行绘制。

我们会在获取光标坐标之后,在 tick 函数中来做这件事,但现在,我们只想试一试,探索一下如何在 canvas 上绘制一张图片。

首先,我们需要加载它。忘掉 TextureLoader 吧,这不是 Three.js。我们需要用纯 JavaScript 加载这个图片。

创建一个 Image 实例,并把它的 src 更新为 glow.png 所在的路径:

// Glow image
displacement.glowImage = new Image()
displacement.glowImage.src = './glow.png'

要把那张图片绘制到 canvas 上,我们可以使用 drawImage() 函数,它的参数与 fillRect() 相同(x、y、width、height),只是把图片作为第一个参数:

displacement.glowImage = new Image()
displacement.glowImage.src = './glow.png'
displacement.context.drawImage(displacement.glowImage, 20, 20, 32, 32)

我们什么都没得到,原因是加载图片需要时间,而我们却试图立刻就绘制它。

我们可以监听 load 事件,但由于这只是一次测试,我们用一个 setTimeout 来等待 1000 毫秒:

displacement.glowImage = new Image()
displacement.glowImage.src = './glow.png'
window.setTimeout(() =>
{
    displacement.context.drawImage(displacement.glowImage, 20, 20, 32, 32)
}, 1000)
lesson demo video preview

它奏效了,我们成功绘制了光晕,但由于我们想在光标所在的 tick 函数中来做这件事,我们把 drawImage() 和 setTimeout() 移除掉:

displacement.glowImage = new Image()
displacement.glowImage.src = './glow.png'

根据光标坐标绘制

我们的 canvas 准备好了,我们也知道如何在它上面绘制光晕了。

现在,我们需要在每一帧根据粒子上的光标位置来绘制那个光晕,我们将使用 Raycaster 来实现。

交互平面

不幸的是,Raycaster 无法用于粒子,因为它需要一个由顶点和三角形构成的几何体。

为了解决这个问题,我们将创建一个与粒子位置完全相同的平面,把它隐藏起来,然后对这个平面使用 Raycaster。

使用 PlaneGeometry 和 MeshBasicMaterial 创建一个 Mesh,把它设为粒子的大小(10, 10),把颜色设为 'red',并把它保存为 interactivePlane:

// Interactive plane
displacement.interactivePlane = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10),
    new THREE.MeshBasicMaterial({ color: 'red' })
)
scene.add(displacement.interactivePlane)

目前,我们先让它保持可见,等我们拿到光标坐标后再把它隐藏起来。

光线投射(Raycaster)

至于 Raycaster,我们现在就可以实例化一个:

// Raycaster
displacement.raycaster = new THREE.Raycaster()

如果你还记得 Raycaster and Mouse Events 那节课,我们还需要光标坐标。由于我们在之前的课程中做过,这里我们就快速带过。

创建一个 Vector2,并把它保存为 screenCursor:

// Coordinates
displacement.screenCursor = new THREE.Vector2()

如果我们让 Vector2 保持这样,光标的默认位置就会在图片的中心,这在体验开始时看起来不太好。

为了解决这个问题,把它设为 9999, 9999:

// Coordinates
displacement.screenCursor = new THREE.Vector2(9999, 9999)

当光标移动时,我们想更新坐标,而且即使是触屏,我们也想这么做。我们可以通过监听 pointermove 事件来实现:

window.addEventListener('pointermove', (event) =>
{
})

在其中,我们会做通常的数学运算,以便把屏幕坐标(以像素为单位)转换为裁剪空间(clip space)坐标(从 -1 到 +1):

window.addEventListener('pointermove', (event) =>
{
    displacement.screenCursor.x = (event.clientX / sizes.width) * 2 - 1
    displacement.screenCursor.y = - (event.clientY / sizes.height) * 2 + 1
})

在 tick 函数中,在更新 controls 之后,使用 setFromCamera() 准备 raycaster:

const tick = () =>
{
    // Update controls
    controls.update()

    /**
     * Raycaster
     */
    displacement.raycaster.setFromCamera(displacement.screenCursor, camera)

    // ...
}

接下来,我们要测试与 interactivePlane 的相交,由于它是单个对象,我们可以使用 intersectObject()(单数形式):

const tick = () =>
{
    // ...
    displacement.raycaster.setFromCamera(displacement.screenCursor, camera)
    const intersections = displacement.raycaster.intersectObject(displacement.interactivePlane)

    // ...
}

尽管我们只测试一个对象,intersectObject() 仍会返回一个数组,以防存在多个交点。但由于我们是在一个平面上测试,我们知道只会有一个交点。

使用 if 语句测试 intersections.length,并 console.log() 第一个(也是唯一一个)交点:

const tick = () =>
{
    // ...
    displacement.raycaster.setFromCamera(displacement.screenCursor, camera)
    const intersections = displacement.raycaster.intersectObject(displacement.interactivePlane)

    if(intersections.length)
    {
        console.log(intersections[0])
    }

    // ...
}
lesson demo video preview

好消息来了。当与一个包含 uv 属性的几何体求交时,我们就能得到 uv 坐标。不需要复杂的计算。

把它保存到一个 uv 变量中并 console.log() 它:

const tick = () =>
{
    // ...

    displacement.raycaster.setFromCamera(displacement.screenCursor, camera)
    const intersections = displacement.raycaster.intersectObject(displacement.interactivePlane)

    if(intersections.length)
    {
        const uv = intersections[0].uv
        console.log(uv)
    }

    // ...
}
lesson demo video preview

在画布上绘制光晕

如果你还记得我们之前绘制光晕的时候,我们用的是像素坐标,而我们刚刚得到的 uv 坐标是归一化的(从 0 到 1)。

我们需要做一些数学运算,但也需要把结果保存在某个地方。

在你创建 screenCursor 的地方,同样使用一个 Vector2 创建 canvasCursor:

// Coordinates
displacement.screenCursor = new THREE.Vector2(9999, 9999)
displacement.canvasCursor = new THREE.Vector2(9999, 9999)

回到我们获取 uv 坐标的 tick:使用 uv 更新 canvasCursor,并乘以 canvas.width 和 canvas.height,这样我们就从归一化坐标转换到了 canvas 坐标:

const tick = () =>
{
    // ...

    if(intersections.length)
    {
        const uv = intersections[0].uv
        
        displacement.canvasCursor.x = uv.x * displacement.canvas.width
        displacement.canvasCursor.y = uv.y * displacement.canvas.height
    }

    // ...
}

我们犯了一个小错误,但我们先继续往下,直到我们注意到问题为止。

现在我们可以绘制光晕了,我们将在 tick 中、整个 Raycaster 之后来做这件事。调用 drawImage() 方法,并把 displacement.canvasCursor 用作 x 和 y 参数:

const tick = () =>
{
    // ...

    /**
     * Raycaster
     */
    // ...

    /**
     * Displacement
     */
    // Draw glow
    displacement.context.drawImage(
        displacement.glowImage,
        displacement.canvasCursor.x,
        displacement.canvasCursor.y,
        32,
        32
    )

    // ...
}
lesson demo video preview

这是个开始,但我们还有几处要修。

首先,光晕被绘制在之前的光晕之上,而黑色区域会覆盖后面的东西。

我们希望颜色能够累积,有点像 Three.js 里的 AdditiveBlending。好消息?在 2D canvas 中我们也能用到这样的东西,我们只需在 context 上把 globalCompositeOperation 设为 'lighten':

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Draw glow
    displacement.context.globalCompositeOperation = 'lighten'
    displacement.context.drawImage(
        displacement.glowImage,
        displacement.canvasCursor.x,
        displacement.canvasCursor.y,
        32,
        32
    )

    // ...
}
lesson demo video preview

你可以看到水平坐标正常工作,但垂直坐标却是反的。这就是我们之前犯的错误。

uv 坐标向上为正,而 canvas 坐标向下为正:

在 if 语句中,把 uv.y 替换为 (1 - uv.y):

const tick = () =>
{
    // ...

    if(intersections.length)
    {
        const uv = intersections[0].uv
        
        displacement.canvasCursor.x = uv.x * displacement.canvas.width
        displacement.canvasCursor.y = (1 - uv.y) * displacement.canvas.height
    }

    // ...
}
lesson demo video preview

最后,我们把光晕绘制在了实际光标坐标的右下方,这意味着光晕并没有以光标为中心:

另外,光晕的大小被设为 32,但如果我们改变 canvas 的大小,比如说把它缩小,光晕看起来就会按比例地过大。

为了解决这个问题,基于 canvas.width 创建一个 glowSize 变量:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Draw glow
    const glowSize = displacement.canvas.width * 0.25

    // ...
}

在 drawImage() 中把它用于 width 和 height,同时从 x 和 y 中减去它的一半,这样它就居中了:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Draw glow
    const glowSize = displacement.canvas.width * 0.25
    displacement.context.globalCompositeOperation = 'lighten'
    displacement.context.drawImage(
        displacement.glowImage,
        displacement.canvasCursor.x - glowSize * 0.5,
        displacement.canvasCursor.y - glowSize * 0.5,
        glowSize,
        glowSize
    )

    // ...
}
lesson demo video preview

淡出效果

我们的光晕一直被绘制,但从不消失。

为了让它淡出,我们会像一开始那样用一个黑色矩形填满整个 canvas,但那个黑色矩形会有较低的不透明度,这样它就不会在绘制时把一切都遮住。

但首先,在绘制光晕之前,让我们试着再次绘制那个黑色矩形:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Fade out
    displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)
    
    // Draw glow
    // ...
}
lesson demo video preview

这很奇怪:它不奏效。原因是我们把 globalCompositeOperation 设成了 'lighten',但从没把它恢复为默认值。

我们可以在 fillRect() 之前把它加回来解决这个问题。默认值是 'source-over':

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Fade out
    displacement.context.globalCompositeOperation = 'source-over'
    displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)
    
    // ...
}
lesson demo video preview

它奏效了,但目前它用一种完全不透明的黑色盖住了一切。我们想让黑色淡出。

为此,把 globalAlpha 设为 0.1:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Fade out
    displacement.context.globalCompositeOperation = 'source-over'
    displacement.context.globalAlpha = 0.1
    displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)
    
    // ...
}
lesson demo video preview

它看起来奏效了,但光晕看起来也淡出了。就像我们在绘制矩形之前把 globalCompositeOperation 放回去一样,我们也需要在绘制光晕之前把 globalAlpha 改回 1:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // ...
    displacement.context.globalAlpha = 1
    displacement.context.drawImage(
        // ...
    )
    
    // ...
}
lesson demo video preview

此时,你可能会根据帧率看到不同的结果。

虽然一旦我们用它来动画粒子,它会稍微改变最终结果,但这几乎察觉不到,我们可以忽略它。

我们的发光拖尾生效了。让我们通过把 globalAlpha 设为 0.02 来让拖尾更长:

const tick = () =>
{
    // ...
    displacement.context.globalAlpha = 0.02
    displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)
    
    // ...
}
lesson demo video preview

如你所见,拖尾永远不会完全消失。这是 2D canvas 的一个常见问题:颜色的精度使得很难引入微小的变化。

别担心,有一个简单的修复方法,等我们在 WebGL 中使用它时就会看到。

我们快要把 canvas 弄完了,我们可以把 interactivePlane 设为不可见:

// Interactive plane
displacement.interactivePlane = new THREE.Mesh(
    // ...
)
displacement.interactivePlane.visible = false

使用 style 缩小 canvas 的大小,使它不遮住粒子:

displacement.canvas.style.width = '256px'
displacement.canvas.style.height = '256px'

在本课中我们会保持 2D canvas 可见,以便展示这项技术并持续观察它,但在正式上线之前你很可能想把它移除。

将位移作为纹理使用

我们的位移 canvas 准备好了,现在我们需要把它发送到粒子 shader。

要把它转换成 Three.js 纹理,我们可以使用 CanvasTexture。

在 Displacement 部分的末尾,创建一个 texture 属性,把一个 CanvasTexture 实例赋给它,并把 canvas 本身(而不是 context)传给它:

// Texture
displacement.texture = new THREE.CanvasTexture(displacement.canvas)

canvas 与 Three.js 纹理完全兼容,而 CanvasTexture 其实不过是一个在实例化时就会立即自我更新的 Texture。

不过,我们依然需要在更新 canvas 之后更新纹理。

在 tick 函数中,在绘制光晕之后,把纹理的 needsUpdate 属性设为 true:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    displacement.context.globalCompositeOperation = 'source-over'
    displacement.context.globalAlpha = 0.02
    displacement.context.fillRect(0, 0, displacement.canvas.width, displacement.canvas.height)

    // Draw glow
    // ...

    // Texture
    displacement.texture.needsUpdate = true

    // ...
}

现在我们可以把这个纹理发送到 shader 了。

使用通常的 Uniform 类创建一个 uDisplacementTexture uniform,并把 displacement.texture 传给它:

const particlesMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        // ...
        uDisplacementTexture: new THREE.Uniform(displacement.texture)
    }
})

在 vertex.glsl 中,以 sampler2D 的形式获取 uDisplacementTexture:

uniform sampler2D uDisplacementTexture;

并且,仅仅为了测试它是否正常工作,把它用于 pictureIntensity,而不是 uPictureTexture:

void main()
{
    // ...

    // Picture
    float pictureIntensity = texture(uDisplacementTexture, uv).r;

    // ...
}
lesson demo video preview

uDisplacementTexture 确实包含了 canvas,我们可以继续了。

把 uPictureTexture 放回来:

void main()
{
    // ...

    // Picture
    float pictureIntensity = texture(uPictureTexture, uv).r;

    // ...
}

位移动画

这一切把我们引到了这一刻。我们终于可以根据位移纹理来移动粒子了。

我们会在把位置发送到 modelPosition 之前先更新它。

首先基于 position 创建一个 vec3 newPosition 变量,并把它发送到 modelPosition:

void main()
{
    // Displacement
    vec3 newPosition = position;

    // Final position
    vec4 modelPosition = modelMatrix * vec4(newPosition, 1.0);
    // ...
}

我们这样做是因为 position 是一个 attribute,而我们无法直接修改 attribute。

提升高度

首先,我们想从 uDisplacementTexture 中获取位移值。

我们可以完全像处理 uPictureTexture 那样来做:根据 uv 使用 texture(),并且只保留 r 通道,因为它是灰度图。把结果保存到 float displacementIntensity 变量中:

void main()
{
    // Displacement
    vec3 newPosition = position;
    float displacementIntensity = texture(uDisplacementTexture, uv).r;

    // ...
}

接下来,创建一个 vec3 displacement 变量,把 x 和 y 设为 0.0,只把 z 设为 1.0:

void main()
{
    // Displacement
    vec3 newPosition = position;
    float displacementIntensity = texture(uDisplacementTexture, uv).r;

    vec3 displacement = vec3(
        0.0,
        0.0,
        1.0
    );

    // ...
}

这是位移的方向,虽然目前只在 z 上,但稍后我们会拿 x 和 y 来做文章以添加随机性,这也是我们使用 vec3 的原因。

我们有作为方向的 displacement,有作为强度的 displacementIntensity,我们可以用 displacementIntensity 乘以 displacement,并把它加到 newPosition 上:

void main()
{
    // Displacement
    vec3 newPosition = position;
    float displacementIntensity = texture(uDisplacementTexture, uv).r;

    vec3 displacement = vec3(
        0.0,
        0.0,
        1.0
    );
    displacement *= displacementIntensity;
    
    newPosition += displacement;

    // ...
}
lesson demo video preview

通过把 displacement 乘以 3.0 来让效果更强:

void main()
{
    // ...

    vec3 displacement = vec3(
        0.0,
        0.0,
        1.0
    );
    displacement *= displacementIntensity;
    displacement *= 3.0;

    // ...
}
lesson demo video preview

随机强度

这是个开始,但效果太规律了。首先,我们要把强度随机化。

在 script.js 中创建一个 aIntensity attribute,填充从 0 到 1 的随机值。由于我们已经做过很多次了,这部分我们快速带过:

const particlesGeometry = new THREE.PlaneGeometry(10, 10, 128, 128)

const intensitiesArray = new Float32Array(particlesGeometry.attributes.position.count)

for(let i = 0; i < particlesGeometry.attributes.position.count; i++)
{
    intensitiesArray[i] = Math.random()
}

particlesGeometry.setAttribute('aIntensity', new THREE.BufferAttribute(intensitiesArray, 1))

在 vertex.glsl 中,以 float 的形式获取它,并用它乘以 displacement:

attribute float aIntensity;

void main()
{
    // ...
    displacement *= displacementIntensity;
    displacement *= 3.0;
    displacement *= aIntensity;
    
    // ...
}
lesson demo video preview

这看起来已经好多了。

随机角度

目前这个效果看起来已经相当不错了,但让我们添加更多随机性。

我们将以各种不同的角度来移动粒子,但每个粒子的角度将始终保持不变,这意味着我们需要一个新的 attribute。

就像我们创建 intensitiesArray 并把它作为 'aIntensity' attribute 发送一样,创建一个填满随机值的 anglesArray,并把它发送到 'aAngle' attribute:

const intensitiesArray = new Float32Array(particlesGeometry.attributes.position.count)
const anglesArray = new Float32Array(particlesGeometry.attributes.position.count)

for(let i = 0; i < particlesGeometry.attributes.position.count; i++)
{
    intensitiesArray[i] = Math.random()
    anglesArray[i] = Math.random()
}

particlesGeometry.setAttribute('aIntensity', new THREE.BufferAttribute(intensitiesArray, 1))
particlesGeometry.setAttribute('aAngle', new THREE.BufferAttribute(anglesArray, 1))

但如果你还记得之前的课程和学校学过的知识,我们需要使用弧度,而由于一个完整的圆是 2 倍的 PI,我们需要把 Math.random() 乘以 Math.PI * 2:

for(let i = 0; i < particlesGeometry.attributes.position.count; i++)
{
    // ...
    anglesArray[i] = Math.random() * Math.PI * 2
}

在 vertex.glsl 中,以 float 的形式获取 aAngle attribute:

attribute float aAngle;

这就是我们把 displacement 设为 vec3 的原因。它已经在 z 上移动了,我们可以用 aAngle 来在 x 和 y 上移动。

aAngle 是一个角度,我们想把它当作是在一个圆上定位某物来使用。为此,我们需要对 x 使用该角度的余弦,对 y 使用正弦:

void main()
{
    // ...

    vec3 displacement = vec3(
        cos(aAngle),
        sin(aAngle),
        1.0
    );
    // ...
}
lesson demo video preview

看起来很棒,但稍微有点太强烈了。把 cos() 和 sin() 都乘以 0.2:

void main()
{
    // ...

    vec3 displacement = vec3(
        cos(aAngle) * 0.2,
        sin(aAngle) * 0.2,
        1.0
    );
    // ...
}
lesson demo video preview

最后,为了确保我们从长度为 1 的向量出发,在创建 displacement 之后立刻对它做 normalize:

void main()
{
    // ...

    vec3 displacement = vec3(
        cos(aAngle) * 0.2,
        sin(aAngle) * 0.2,
        1.0
    );
    displacement = normalize(displacement);
    // ...
}
lesson demo video preview

修复画布问题

如果你移动光标一会儿,你会注意到粒子不会回到它们的初始位置。

这是因为正如之前所展示的,拖尾在 2D canvas 中并不会完美地淡出。

为了解决这个问题,我们要对 displacementIntensity 进行重映射,以便忽略 0.1 以下的一切:

现在,这是一个使用 smoothstep 来让数值变得平滑的绝佳机会:

void main()
{
    // Displacement
    vec3 newPosition = position;
    float displacementIntensity = texture(uDisplacementTexture, uv).r;
    displacementIntensity = smoothstep(0.1, 1.0, displacementIntensity);

    // ...
}
lesson demo video preview

粒子现在会回到它们的初始位置了。

拖尾效果

我们的粒子动画看起来完成了,但等等,还有更多。如果我们能让粒子在回到初始位置之前,先保持一会儿它们的位置呢?

使用 smoothstep,我们还可以忽略高于某个值的部分,比如说 0.3。这意味着当 displacementIntensity 高于 0.3 时,它实际上会保持为 1.0,直到它降到 0.3 及以下:

void main()
{
    // Displacement
    vec3 newPosition = position;
    float displacementIntensity = texture(uDisplacementTexture, uv).r;
    displacementIntensity = smoothstep(0.1, 0.3, displacementIntensity);

    // ...
}
lesson demo video preview

性能优化

尽管你察觉不到,但实际被渲染的粒子比我们看到的要多得多。

有一个方法可以看到它,那就是在材质上把 blending 设为 THREE.AdditiveBlending:

const particlesMaterial = new THREE.ShaderMaterial({
    // ...
    blending: THREE.AdditiveBlending
})

看到粒子看起来亮多了吗?

如果网格上每个位置只有一个粒子,那么无论有没有 blending,它们都应该保持相同的颜色。但由于有多个粒子,它们的颜色被叠加起来,结果我们得到了更亮的粒子。

出现这种情况是因为这个几何体使用了索引(index)。

记住,几何体通常用于绘制三角形,而一个三角形由三个顶点组成。如果没有索引,顶点数量将等于三角形数量的 3 倍:

使用索引时,顶点是唯一的,而三角形由指向这些顶点的索引来构成,这可以带来小得多的缓冲区:

这是一项很棒的技术,但由于我们绘制的是粒子,我们不关心三角形。

要去掉索引,使用几何体上的 setIndex() 方法并把它设为 null:

const particlesGeometry = new THREE.PlaneGeometry(10, 10, 128, 128)
particlesGeometry.setIndex(null)

亮度恢复正常了,这意味着网格上每个位置只有一个粒子。

既然我们已经在这里了,让我们移除那个我们没用的 normal attribute:

const particlesGeometry = new THREE.PlaneGeometry(10, 10, 128, 128)
particlesGeometry.setIndex(null)
particlesGeometry.deleteAttribute('normal')

修复背面问题

目前,如果我们把 camera 移动到另一侧,效果就不起作用了:

lesson demo video preview

这是因为 Raycaster 只在三角形的正面(front side)起作用。

幸运的是,我们可以通过把 interactivePlane 材质的 side 属性设为 THREE.DoubleSide 来指示 Raycaster 检查两侧:

displacement.interactivePlane = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10),
    new THREE.MeshBasicMaterial({ color: 'red', side: THREE.DoubleSide })
)
lesson demo video preview

速度与透明度

如果我们停止移动光标,粒子会保持抬起的状态:

lesson demo video preview

这看起来有点奇怪,我们希望效果能根据光标的速度而变化。

为了计算速度,我们将在每一帧计算光标当前位置与其之前位置之间的距离:

距离越大,光标就越快。

首先,由于我们想把光标位置与其之前的值进行比较,我们需要把那个之前的值保存在某个地方。

在我们创建 canvasCursor 的地方,使用另一个 Vector2 创建 canvasCursorPrevious,值相同:

// Coordinates
displacement.screenCursor = new THREE.Vector2(9999, 9999)
displacement.canvasCursor = new THREE.Vector2(9999, 9999)
displacement.canvasCursorPrevious = new THREE.Vector2(9999, 9999)

在 tick 函数中,就在绘制光晕之前,使用 Vector2 上可用的 copy() 方法把 canvasCursor 的值复制到 canvasCursorPrevious:

const tick = () =>
{
    // ...

    /**
     * Displacement
     */
    // Fade out
    // ...

    // Speed alpha
    displacement.canvasCursorPrevious.copy(displacement.canvasCursor)
    
    // Draw glow
    // ...
}

copy() 方法从一个向量(在我们的情况中是 x 和 y)取值,并把它们赋给另一个向量,而不替换实例。

现在,在 copy() 之前,我们将使用 Vector2 上可用的 distanceTo() 方法计算两个向量之间的距离,并把它保存为 cursorDistance 变量:

const tick = () =>
{
    // ...
    // ...

    // Speed alpha
    const cursorDistance = displacement.canvasCursorPrevious.distanceTo(displacement.canvasCursor)
    displacement.canvasCursorPrevious.copy(displacement.canvasCursor)
    
    // ...
}

在 copy() 之前做这件事很重要,这样我们才是与之前的值比较,否则它将一直是 0。

出于好奇 console.log() 一下 cursorDistance:

const tick = () =>
{
    // ...

    // Speed alpha
    const cursorDistance = displacement.canvasCursorPrevious.distanceTo(displacement.canvasCursor)
    displacement.canvasCursorPrevious.copy(displacement.canvasCursor)
    console.log(cursorDistance)
    
    // ...
}
lesson demo video preview

移除 console.log(),创建一个 alpha 变量并把 cursorDistance 乘以 0.1 赋给它,这样 alpha 就不会太高:

const tick = () =>
{
    // ...

    // Speed alpha
    const cursorDistance = displacement.canvasCursorPrevious.distanceTo(displacement.canvasCursor)
    displacement.canvasCursorPrevious.copy(displacement.canvasCursor)
    const alpha = cursorDistance * 0.1
    
    // ...
}

虽然没必要,但我们还是用 Math.min() 把它限制在 1:

const tick = () =>
{
    // ...

    // Speed alpha
    const cursorDistance = displacement.canvasCursorPrevious.distanceTo(displacement.canvasCursor)
    displacement.canvasCursorPrevious.copy(displacement.canvasCursor)
    const alpha = Math.min(cursorDistance * 0.1, 1)
    
    // ...
}

最后,我们可以在调用 drawImage() 之前,把 alpha 赋给 globalAlpha 属性:

const tick = () =>
{
    // ...

    // Draw glow
    // ...
    displacement.context.globalAlpha = alpha
    displacement.context.drawImage(
        // ...
    )
    
    // ...
}
lesson demo video preview

现在如果我们停止移动光标,效果就会停止。

进一步探索

我们终于完成了,但体验还可以改进。

以下是一些建议: